The Windows operating system is heavily based on messages. For example, when the user closes a window, the operating system sends the window a WM_CLOSE message. When the user types a key, the window that has the focus receives a WM_CHAR message, and so on. (In this context, the term window refers to both top-level windows and child controls.) Messages can also be sent to a window or a control to affect its appearance or behavior or to retrieve the information it contains. For example, you can send the WM_SETTEXT message to most windows and controls to assign a string to their contents, and you can send the WM_GETTEXT message to read their current contents. By means of these messages you can set or read the caption of a top-level window or set or read the Text property of a TextBox control, just to name a few common uses for this technique.
Broadly speaking, messages belong to one of two families: They're control messages or notification messages. Control messages are sent by an application to a window or a control to set or retrieve its contents, or modify its behavior or appearance . Notification messages are sent by the operating system to windows or controls as the result of the actions users perform on them.
Visual Basic greatly simplifies the programming of Windows applications because it automatically translates most of these messages into properties, methods, and events. Instead of using WM_SETTEXT and WM_GETTEXT messages, Visual Basic programmers can reason in terms of Caption and Text properties. Nor do they have to worry about trapping WM_CLOSE messages sent to a form because the Visual Basic runtime automatically translate them into Form_Unload events. More generally, control messages map to properties and methods, whereas notification messages map to events.
Not all messages are processed in this way, though. For example, the TextBox control has built-in undo capabilities, but they aren't exposed as properties or methods by Visual Basic and therefore they can't be accessed by "pure" Visual Basic code. (In this chapter, pure Visual Basic means code that doesn't rely on external API functions.) Here's another example: When the user moves a form, Windows sends the form a WM_MOVE message, but the Visual Basic runtime traps that message without raising an event. If your application needs to know when one of its windows moves, you're out of luck.
By using API functions, you can work around these limitations. In this section, I' show you how you can send a control message to a window or a control to affect its appearance or behavior, while in the "Callback and Subclassing" section of this chapter, I' illustrate a more complex programming technique, called window subclassing, which lets you intercept the notification messages that Visual Basic doesn't translate to events.
Before you can use an API function, you must tell Visual Basic the name of the DLL that contains it and the type of each argument. You do this with a Declare statement, which must appear in the declaration section of a module. Declare statements must be declared as Private in all types of modules except BAS modules (which also accept Public Declare statements that are visible from the entire application). For additional information about the Declare statement, see the language documentation.
The main API function that you can use to send a message to a form or a control is SendMessage, whose Declare statement is this:
Private Declare Function SendMessage Lib "user32" Alias "SendMessageA" _ (ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, lParam As Any) As Long |
The hWnd argument is the handle of the window to which you're sending the message (it corresponds to the window's hWnd property), wMsg is the message number (usually expressed as a symbolic constant), and the meaning of the wParam and lParam values depend on the particular message you're sending. Notice that lParam is declared with the As Any clause so that you can pass virtually anything to this argument, including any simple data type or a UDT. To reduce the risk of accidentally sending invalid data, I've prepared a version of the SendMessage function, which accepts a Long number by value, and another version that expects a String passed by value. These are the so called type-safe Declare statements:
Private Declare Function SendMessageByVal Lib "user32" _ Alias "SendMessageA" (ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, Byval lParam As Long) As Long Private Declare Function SendMessageString Lib "user32" _ Alias "SendMessageA" ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, ByVal lParam As String) As Long |
Apart from such type-safe variants, the Declare functions used in this chapter, as well as the values of message symbolic constants, can be obtained by running the API Viewer utility that comes with Visual Basic. (See Figure A-1.)
CAUTION
When working with API functions, you're in direct touch with the operating system and aren't using the safety net that Visual Basic offers. If you make an error in the declaration or execution of an API function, you're likely to get a General Protection Fault (GPF) or another fatal error that will immediately shut down the Visual Basic environment. For this reason, you should carefully double-check the Declare statements and the arguments you pass to an API function, and you should always save your code before running the project.
Figure A-1. The API Viewer utility has been improved in Visual Basic 6 with the capability to set the scope of Const and Type directives, and Declare statements.
The SendMessage API function is very useful with multiline TextBox controls because only a small fraction of their features is exposed through standard properties and methods. For example, you can determine the number of lines in a multiline TextBox control by sending it an EM_GETLINECOUNT message:
LineCount = SendMessageByVal(Text1.hWnd, EM_GETLINECOUNT, 0, 0) |
or you can use the EM_GETFIRSTVISIBLELINE message to determine which line is the first visible line. (Line numbers are zero-based.)
FirstVisibleLine = SendMessageByVal(Text1.hWnd, EM_GETFIRSTVISIBLELINE, 0, 0) |
NOTE
All the examples shown in this chapter are available on the companion CD. To help make the code more easily reusable, I've encapsulated all the examples in Function and Sub routines and stored them in BAS modules. Each module contains the declaration of the API functions used, as well as the Const directives that define all the necessary symbolic constants. On the CD. you'll also find a demonstration program that shows all the routines in action. (See Figure A2.)
The EM_LINESCROLL message enables you to programmatically scroll the contents of a TextBox control in four directions. You must pass the number of columns to scroll horizontally in wParam (positive values scroll right, negative values scroll left) and the number of lines to scroll vertically in lParam (positive values scroll down, negative values scroll up).
' Scroll one line down and (approximately) 4 characters to the right. SendMessageByVal Text1.hWnd, EM_LINESCROLL, 4, 1 |
Figure A-2. The program that demonstrates how to use the routines in the TextBox.bas module.
Notice that the number of columns used for horizontal scrolling might not correspond to the actual number of character scrolled if the TextBox control uses a nonfixed fonts. Moreover, horizontal scrolling doesn't work if the ScrollBars property is set to 2-Vertical. You can scroll the control's contents to ensure that the caret is visible using the EM_SCROLLCARET:
SendMessageByVal Text1.hWnd, EM_SCROLLCARET, 0, 0 |
One of the most annoying limitations of the standard TextBox control is that there is no way to find out how longer lines of text are split into multiple lines. Using the EM_FMTLINES message, you can ask the control to include the so-called soft line breaks in the string returned by its Text property. A soft line break is the point where the control splits a line because it's too long for the control's width. A soft line break is represented by the sequence CR-CR-LF. Hard line breaks, points at which the user has pressed the Enter key, are represented by the CR-LF sequence. When sending the EM_FMTLINES message, you must pass True in wParam to activate soft line breaks, and False to disable them. I've prepared a routine that uses this feature to fill a String array with all the lines of text, as they appear in the control:
' Return an array with all the lines in the control. ' If the second optional argument is True, trailing CR-LFs are preserved. Function GetAllLines(tb As TextBox, Optional KeepHardLineBreaks _ As Boolean) As String() Dim result() As String, i As Long ' Activate soft line breaks. SendMessageByVal tb.hWnd, EM_FMTLINES, True, 0 ' Retrieve all the lines in one operation. This operation leaves ' a trailing CR character for soft line breaks. result() = Split(tb.Text, vbCrLf) ' We need a loop to trim the residual CR characters. If the second ' argument is True, we manually add a CR-LF pair to all the lines that ' don't contain the residual CR char (they were hard line breaks). For i = 0 To UBound(result) If Right$(result(i), 1) = vbCr Then result(i) = Left$(result(i), Len(result(i)) - 1) ElseIf KeepHardLineBreaks Then result(i) = result(i) & vbCrLf End If Next ' Deactivate soft line breaks. SendMessageByVal tb.hWnd, EM_FMTLINES, False, 0 GetAllLines = result() End Function |
You can also retrieve one single line of text, using the EM_LINEINDEX message to determine where the line starts and the EM_LINELENGTH to determine its length. I've prepared a reusable routine that puts these two messages together:
Function GetLine(tb As TextBox, ByVal lineNum As Long) As String Dim charOffset As Long, lineLen As Long ' Retrieve the character offset of the first character of the line. charOffset = SendMessageByVal(tb.hWnd, EM_LINEINDEX, lineNum, 0) ' Now it's possible to retrieve the length of the line. lineLen = SendMessageByVal(tb.hWnd, EM_LINELENGTH, charOffset, 0) ' Extract the line text. GetLine = Mid$(tb.Text, charOffset + 1, lineLen) End Function |
The EM_LINEFROMCHAR message returns the number of the line given a character's offset; you can use this message and the EM_LINEINDEX message to determine the line and column coordinates of a character:
' Get the line and column coordinates of a given character. ' If charIndex is negative, it returns the coordinates of the caret. Sub GetLineColumn(tb As TextBox, ByVal charIndex As Long, line As Long, _ column As Long) ' Use the caret's offset if argument is negative. If charIndex < 0 Then charIndex = tb.SelStart ' Get the line number. line = SendMessageByVal(tb.hWnd, EM_LINEFROMCHAR, charIndex, 0) ' Get the column number by subtracting the line's start ' index from the character position. column = tb.SelStart - SendMessageByVal(tb.hWnd, EM_LINEINDEX, line, 0) End Sub |
Standard TextBox controls use their entire client area for editing. You can retrieve the dimension of such a formatting rectangle using the EM_GETRECT message, and you can use the EM_SETRECT to modify its size as your needs dictate. In each instance, you need to include the definition of the RECT structure, which is also used by many other API functions:
Private Type RECT Left As Long Top As Long Right As Long Bottom As Long End Type |
I've prepared two routines that encapsulate these messages:
' Get the formatting rectangle. Sub GetRect(tb As TextBox, Left As Long, Top As Long, Right As Long, _ Bottom As Long) Dim lpRect As RECT SendMessage tb.hWnd, EM_GETRECT, 0, lpRect Left = lpRect.Left: Top = lpRect.Top Right = lpRect.Right: Bottom = lpRect.Bottom End Sub ' Set the formatting rectangle and refresh the control. Sub SetRect(tb As TextBox, ByVal Left As Long, ByVal Top As Long, _ ByVal Right As Long, ByVal Bottom As Long) Dim lpRect As RECT lpRect.Left = Left: lpRect.Top = Top lpRect.Right = Right: lpRect.Bottom = Bottom SendMessage tb.hWnd, EM_SETRECT, 0, lpRect End Sub |
For example, see how you can shrink the formatting rectangle along its horizontal dimension:
Dim Left As Long, Top As Long, Right As Long, Bottom As Long GetRect tb, Left, Top, Right, Bottom Left = Left + 10: Right = Right - 10 SetRect tb, Left, Top, Right, Bottom |
One last thing that you can do with multiline TextBox controls is to set their tab stop positions. By default, the tab stops in a TextBox control are set at 32 dialog units from one stop to the next, where each dialog unit is one-fourth the average character width. You can modify such default distances using the EM_SETTABSTOPS message, as follows:
' Set the tab stop distance to 20 dialog units ' (that is, 5 characters of average width). SendMessage Text1.hWnd, EM_SETTABSTOPS, 1, 20 |
You can even control the position of each individual tab stop by passing this message an array of Long elements in lParam as well as the number of elements in the array in wParam. Here's an example:
Dim tabs(1 To 3) As Long ' Set three tab stops approximately at character positions 5, 8, and 15. tabs(1) = 20: tabs(2) = 32: tabs(3) = 60 SendMessage Text1.hWnd, EM_SETTABSTOPS, 3, tabs(1) |
Notice that you pass an array to an API function by passing its first element by reference.
Next to TextBox controls, ListBox and ComboBox are the intrinsic controls that benefit most from the SendMessage API function. In this section, I describe the messages you can send to a ListBox control. In some situations, you can send a similar message to the ComboBox control as well to get the same result, even if the numeric value of the message is different. For example, you can retrieve the height in pixels of an item in the list portion of these two controls by sending them the LB_GETITEMHEIGHT (if you're dealing with a ListBox control) or the CB_GETITEMHEIGHT (if you're dealing with a ComboBox control). I've encapsulated these two messages in a polymorphic routine that works with both types of controls. (See Figure A-3.)
' The result of this routine is in pixels. Function GetItemHeight(ctrl As Control) As Long Dim uMsg As Long If TypeOf ctrl Is ListBox Then uMsg = LB_GETITEMHEIGHT ElseIf TypeOf ctrl Is ComboBox Then uMsg = CB_GETITEMHEIGHT Else Exit Function End If GetItemHeight = SendMessageByVal(ctrl.hwnd, uMsg, 0, 0) End Function |
Figure A-3. The demonstration program for using the SendMessage function with ListBox and ComboBox controls.
You can also set a different height for the list items by using the LB_SETITEMHEIGHT or CB_SETITEMHEIGHT message. While the height of an item isn't valuable information in itself, it lets you evaluate the number of visible elements in a ListBox control, data that isn't exposed as a property of the Visual Basic control. You can evaluate the number of visible elements by dividing the height of the internal area of the control—also known as the client area of the control—by the height of each item. To retrieve the height of the client area, you need another API function, GetClientRect:
Private Declare Function GetClientRect Lib "user32" (ByVal hWnd As Long, _ lpRect As RECT) As Long |
This is the function that puts all the pieces together and returns the number of items in a ListBox control that are entirely visible:
Function VisibleItems(lb As ListBox) As Long Dim lpRect As RECT, itemHeight As Long ' Get client rectangle area. GetClientRect lb.hWnd, lpRect ' Get the height of each item. itemHeight = SendMessageByVal(lb.hWnd, LB_GETITEMHEIGHT, 0, 0) ' Do the division. VisibleItems = (lpRect.Bottom - lpRect.Top) \ itemHeight End Function |
You can use this information to determine whether the ListBox control has a companion vertical scroll bar control:
HasCompanionScrollBar = (Visibleitems(List1) < List1.ListCount) |
Windows provides messages for quickly searching for a string among the items of a ListBox or ComboBox control. More precisely, there are two messages for each control, one that performs a search for a partial match—that is, the search is successful if the searched string appears at the beginning of an element in the list portion—and one that looks for exact matches. You pass the index of the element from which you start the search to wParam (-1 to start from the very beginning), and the string being searched to lParam by value. The search isn't case sensitive. Here's a reusable routine that encapsulates the four messages and returns the index of the matching element or -1 if the search fails. Of course, you can reach the same result with a loop over the ListBox items, but the API approach is usually faster:
Function FindString(ctrl As Control, ByVal search As String, Optional _ startIndex As Long = -1, Optional ExactMatch As Boolean) As Long Dim uMsg As Long If TypeOf ctrl Is ListBox Then uMsg = IIf(ExactMatch, LB_FINDSTRINGEXACT, LB_FINDSTRING) ElseIf TypeOf ctrl Is ComboBox Then uMsg = IIf(ExactMatch, CB_FINDSTRINGEXACT, CB_FINDSTRING) Else Exit Function End If FindString = SendMessageString(ctrl.hwnd, uMsg, startIndex, search) End Function |
Because the search starts with the element after the startIndex position, you can easily create a loop that prints all the matching elements:
' Print all the elements that begin with the "J" character. index = -1 Do index = FindString(List1, "J", index, False) If index = -1 Then Exit Do Print List1.List(index) Loop |
A ListBox control can display a horizontal scroll bar if its contents are wider than its client areas, but this is another capability that isn't exposed by the Visual Basic control. To make the horizontal scroll bar appear, you must tell the control that it contains elements that are wider than its client area. (See Figure A-3.) You do this using the LB_SETHORIZONTALEXTENT message, which expects a width in pixels in the wParam argument:
' Inform the ListBox control that its contents are 400 pixels wide. ' If the control is narrower, a horizontal scroll bar will appear. SendMessageByVal List1.hwnd, LB_SETHORIZONTALEXTENT, 400, 0 |
You can add a lot of versatility to standard ListBox controls by setting the positions of their tab stops. The technique is similar to the one used for TextBox controls. If you add to that the ability to display a horizontal scroll bar, you see that the ListBox control becomes a cheap means for displaying tables—you don't have to resort to external ActiveX controls. All you have to do is set the tab stop position to a suitable distance and then add lines of tab-delimited elements, as in the following code:
' Create a 3-column table using a ListBox. ' The three columns hold 5, 20, and 25 characters of average width. Dim tabs(1 To 2) As Long tabs(1) = 20: tabs(2) = 100 SendMessage List1.hWnd, LB_SETTABSTOPS, 2, tabs(1) ' Add a horizontal scroll bar, if necessary. SendMessageByVal List1.hwnd, LB_SETHORIZONTALEXTENT, 400, 0 List1.AddItem "1" & vbTab & "John" & vbTab & "Smith" List1.AddItem "2" & vbTab & "Robert" & vbTab & "Doe" |
You can learn how to use a few other ListBox messages by browsing the source code of the demonstration program provided on the companion CD.
As I explained in the previous section, ComboBox and ListBox controls supports some common messages, even though the names and the values of the corresponding symbolic constants are different. For example, you can read and modify the height of items in the list portion using the CB_GETITEMHEIGHT and CB_SETITEMHEIGHT messages, and you can search items using the CB_FINDSTRINGEXACT and CB_FINDSTRING messages.
But the ComboBox control also supports other interesting messages. For example, you can programmatically open and close the list portion of a drop-down ComboBox control using the CB_SHOWDROPDOWN message:
' Open the list portion. SendMessageByVal Combo1.hWnd, CB_SHOWDROPDOWN, True, 0 ' Then close it. SendMessageByVal Combo1.hWnd, CB_SHOWDROPDOWN, False, 0 |
and you can retrieve the current visibility state of the list portion using the CB_GETDROPPEDSTATE message:
If SendMessageByVal(Combo1.hWnd, CB_GETDROPPEDSTATE, 0, 0) Then ' The list portion is visible. End If |
One of the most useful messages for ComboBox controls is CB _SETDROPPEDWIDTH, which lets you set the width of the ComboBox drop-down list although values less than the control's width are ignored:
' Make the drop-down list 300 pixels wide. SendMessageByVal cb.hwnd, CB_SETDROPPEDWIDTH, 300, 0 |
(See Figure A-3 for an example of a ComboBox whose drop-down list is wider than usual.)
Finally, you can use the CB_LIMITTEXT message to set a maximum number of characters for the control; this is similar to the MaxLength property for TextBox controls, which is missing in ComboBox controls:
' Set the maximum length of text in a ComboBox control to 20 characters. SendMessageByVal Combo1.hWnd, CB_LIMITTEXT, 20, 0 |